<?php
/**
 * POST-intercepting Asirra HIP/CAPTCHA
 * Copyright (C) 2009 Marco Dieckhoff
 * 
 * Asirra is a human interactive proof (HIP/CAPTCHA) that asks users to
 * identify photos of cats and dogs. http://www.asirra.com
 * 
 * This class intercepts POST requests, stores them in a session and asks
 * for an Asirra CAPTCHA test. If successful, the POST action is resumed.
 *
 * Success is stored in a Cookie to enable further webpage usage without
 * being harassed by CAPTCHAs all the time. 
 *
 * Asirra needs PHP cURL access to http://challenge.asirra.com/cgi/Asirra
 * 
 * You may change the time until a ticket gets invalid (default 720 min = 12h)
 * You should change an encryption secret ingredient (see first lines of code)
 *
 * @author Marco Dieckhoff <asirra@auffangbecken.de>
 * @version 0.1
 * @license The GNU General Public License (GPLv3)
 *  
 * This program is free software; you can redistribute it and/or modify 
 * it under the terms of the GNU General Public License as published by 
 * the Free Software Foundation; either version 3 of the License, or (at
 * your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but 
 * WITHOUT ANY WARRANTY; without even the implied warranty of 
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, see <http://www.gnu.org/licenses/>.
 * 
 */
class Asirra {

	/**
	 * sets the time in minutes till forced re-take of the Asirra challenge
	 * a value of -1 disables forced re-checks and will wait until the cookie or session is finished
	 */
	private $TicketValidTime = 720;
	
	/**
	 * The AES key is generated by Server address, software idenfier, remote address and user agent.
	 * It's quite simple to forge a cookie based solely on this information.
	 * To ensure safety you should change the Secret Ingredient for the AES key to differ from the default.
	 */
	private $AESSecretIngredient = 'change me, please... PLEASE!';
	


	  ############################################
	 ##                                          ##
	 ##   Nothing to change beyond this point    ##
	 ##                                          ##
	  ############################################

	
	
	/*
	 * internally used variables
	 */
	private $AESkey;
	private $AES;
	
	
	
	/**
	 * Generate AES key
	 */
	private function genAESkey() {
		// maybe remove REMOTE_ADDR for longer validity if User IPs are dynamically assigned
		
		$keybasis = $this->AESSecretIngredient
			.$_SERVER['SERVER_ADDR']
			.$_SERVER['SERVER_SOFTWARE']
			.$_SERVER['REMOTE_ADDR']
			.$_SERVER['HTTP_USER_AGENT']; 

		$this->AESkey = md5($keybasis);
	}
	
	/**
	 * initializes AES class
	 */
	private function initAES() {
		if (!isset($AESkey)) $this->genAESkey();
		$this->AES = new AESCipher(AES::AES256);
	}
	
	/**
	 * Encrypts text
	 *
	 * @param string $data
	 * @return string AES encrypted $data
	 */
	private function encrypt($data) {
		return $this->AES->encrypt($data, $this->AESkey);
	}

	/**
	 * Decrypts text
	 *
	 * @param string $data
	 * @return string AES decrypted $data
	 */
	private function decrypt($data) {
		return $this->AES->decrypt($data, $this->AESkey);
	}
	
	
	/**
	 * shows HTML page with Asirra request
	 * The Intercept HTML code has to be in the directory from where Asirra is included,
	 * or you have to edit the path in the Asirra.class.php
	 */
	private function showInterceptPage() {
		$html = file_get_contents('Asirra.intercept.html');
		$html = str_replace('{FORMACTION}', $_SESSION['AssiraRequestURI'], $html);
		print $html;		
		// do nothing more than showing the intercept page
		die("");
	}

	/*
	 * variables for Asirra Challenge checks
	 */
	private $checkAsirraChallenge_inResult;
	private $checkAsirraChallenge_passed;
	private $checkAsirraChallenge_xmlparser;
	
	/**
	 * startElement handler for XML Parser 
	 *
	 * @param XMLParser $parser
	 * @param String $name
	 * @param mixed $attrs
	 */
	private function checkAsirraChallenge_startElement($parser, $name, $attrs) {
		$this->checkAsirraChallenge_inResult = ($name=="RESULT");
	}
	
	/**
	 * endElement handler for XML Parser
	 *
	 * @param String $name
	 */
	private function checkAsirraChallenge_endElement($name) {
		$this->checkAsirraChallenge_inResult = false;
	}

	/**
	 * characterData handler for XML Parser
	 *
	 * @param XMLParser $parser
	 * @param String $data
	 */
	private function checkAsirraChallenge_characterData($parser, $data) {
		if ($this->checkAsirraChallenge_inResult && $data == "Pass") {
			$this->checkAsirraChallenge_passed = true;
		}
	}
	
	/**
	 * checks if the submitted Asirra Ticket is valid
	 *
	 * @return bool validity
	 */
	private function checkAsirraChallenge() {
		// based on example code from http://www.asirra.com/examples/ExampleService-PHP.php.txt

		// assume failure
		$this->checkAsirraChallenge_passed = false;
		
		$AsirraServiceUrl = "http://challenge.asirra.com/cgi/Asirra";
		$ticket = $_POST['Asirra_Ticket'];
		$url = $AsirraServiceUrl."?action=ValidateTicket&ticket=".$ticket;
	
		// get the ticket information
		if (function_exists('curl_init')) {
			$ch = curl_init();
			curl_setopt($ch, CURLOPT_URL, $url);
			curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
			curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
			$resultXml = curl_exec($ch);
			curl_close($ch);
			
		} elseif (ini_get('allow_url_fopen')) {
			$resultXml = file_get_contents($url);
		} else {
			die("no method to access http://challenge.asirra.com found");
		}
	
		// parse the XML ticket -- if "Pass" data is found, the
		// character handler will set checkAsirraChallenge_passed
		
		$this->checkAsirraChallenge_xmlparser = xml_parser_create();
		xml_set_object($this->checkAsirraChallenge_xmlparser, $this);
		xml_set_element_handler($this->checkAsirraChallenge_xmlparser, 'checkAsirraChallenge_startElement', 'checkAsirraChallenge_endElement');
		xml_set_character_data_handler($this->checkAsirraChallenge_xmlparser, 'checkAsirraChallenge_characterData');
		xml_parse($this->checkAsirraChallenge_xmlparser, $resultXml, 1);
		xml_parser_free($this->checkAsirraChallenge_xmlparser);
		
		return $this->checkAsirraChallenge_passed;
	}
	
	/**
	 * sets a Cookie containing the encrypted time of the successful challenge
	 */
	private function setValidAsirra() {
		$cookiename = 'AsirraTicketChallengeTime';

		$ChallengeTime = time().""; // AES can only handle strings
		$cookievalue = $this->encrypt($ChallengeTime);
		
		$cookieexpiration = time() + 30 * 24 * 60 * 60; // 30 days cookie time
		
		setcookie($cookiename, $cookievalue, $cookieexpiration);
	}
	
	/**
	 * checks for the Cookie with the last successful challenge
	 *
	 * @return Cookie exists and is valid
	 */
	private function existsValidAsirra() {
		$cookiename = 'AsirraTicketChallengeTime';
		
		if (isset($_COOKIE[$cookiename])) {
			$cookiedata = $this->decrypt($_COOKIE[$cookiename]);
			
			// only digits?
			if (preg_match('/^\d+$/', $cookiedata)) {
			
				// OK, if value exists and valid time is -1 = unlimited
				if ($this->TicketValidTime === -1) return true; 

				// OK, if Cookie is not too old
				if (time() - $_COOKIE[$cookiename] > $this->TicketValidTime * 60) return true;
				
			} else  {
				// cookie is not a number
				setcookie($cookiename, "", time()-10000);
			}
		}
		// any other case: expired / non-existent yet
		return false;
	}
	
	/**
	 * stores the POST request and requested page into a php session
	 * deletes all Asirra based POST keys from the storage 
	 */
	private function storePOST() {
		$_SESSION['AsirraPOST'] = $_POST;
		foreach ($_SESSION['AsirraPOST'] as $key => $value) {
			if (preg_match('/^Asirra/', $key)) {
				unset($_SESSION['AsirraPOST'][$key]);
			}
		}
		$_SESSION['AssiraRequestURI'] = $_SERVER['REQUEST_URI'];
	}

	/**
	 * restores POST request, also sets $_REQUEST
	 */
	private function restorePOST() {
		$_POST = $_SESSION['AsirraPOST'];
		foreach ($_POST as $key => $value) $_REQUEST[$key] = $value;	
	}
	
	/**
	 * main handling of incoming POST requests
	 */
	function checkPOST() {
		// only do something if POST exists and is used by some variable
		if ((isset($_POST)) and (count($_POST)>0)) {
			
			// do nothing if files are uploaded in this POST -- can't handle them correctly
			if  ((isset($_FILES)) and (count($_FILES)>0)) return false;
			
			// challenge taken
			if ($_POST['Asirra_Ticket']) {
				
				// challenge successful: restore POST
				if ($this->checkAsirraChallenge()) {
					$this->setValidAsirra();
					$this->restorePOST();

				// challenge not successful: show intercept page again
				} else {
					// without saving POST -- was already done before
					$this->showInterceptPage();
				}
			
			// challenge not taken
			} elseif (!$this->existsValidAsirra()) {
				$this->storePOST();
				$this->showInterceptPage();
			}
			
			
		}
	}

	/**
	 * Create Asirra Challenge instance
	 * If $instantly is set to false you have to call ->checkPOST() manually 
	 *
	 * @param bool $instantly
	 */
	function __construct($instantly = true) {
		if (!isset($_SESSION)) session_start();

		require_once('AES.class.php');
		require_once('AESCipher.class.php');
		$this->initAES();
		
		if ($instantly) $this->checkPOST();
	}
	
	
}

?>